#!/usr/bin/ruby


require 'optparse'
require 'win32ole'


#A helper that controls the iTunes application.
class Itch


	#The COM/OLE interface to iTunes.
	attr_reader :interface
	
	
	#Create a new helper, and assign it an iTunes interface to use.
	def initialize(itunes_interface)
		@interface = itunes_interface
	end
	
	
	#Create an OLE/COM link to iTunes.
	def Itch.create_itunes_interface
		begin
			WIN32OLE.new('iTunes.Application') or raise "Couldn't take control of iTunes."
		rescue WIN32OLERuntimeError => exception
			raise exception.exception("Couldn't take control of iTunes.  Could a prior instance be shutting down?")
		end
	end
	
	
	#Parse arguments and get a configuration.
	def parse_options(arguments)

		#Config will hold parsed option values.
		config = Hash.new

		#Set up option parser.
		options = OptionParser.new
		#Modify option parser to wrap descriptions.
		class <<options
			alias old_on on
			def on (*opts, &block)
				opts.map! do |option|
					if option.class == String and option.length > 40
						option = option.scan(/\S.{0,40}\S(?=\s|$)|\S+/)
					end
					option
				end
				old_on(*opts.flatten, &block)
			end
		end
		
		#Set up valid options.
		
		options.separator("Program help:")
		options.on("-h", "--help", TrueClass, "Display program help.") {
			puts options.help
			exit
		}
		
		options.separator("Playback controls:")
		options.on("-p", "--play-pause", TrueClass, "If currently paused, begin playing.  If currently playing, pause playback.") {|value| config['play-pause'] = value}
		options.on("--pause", TrueClass, "Pause playback.") {|value| config['pause'] = value}
		options.on("--play", TrueClass, "Play the current track.") {|value| config['play'] = value}
		options.on("-s", "--stop", TrueClass, "Stop playback.") {|value| config['stop'] = value}
		options.on("-n", "--next-track", TrueClass, "Go to the next track.") {|value| config['next-track'] = value}
		options.on("-N", "--previous-track", TrueClass, "Go to the previous track.") {|value| config['previous-track'] = value}
		options.on("-m", "--mute", TrueClass, "Mute the audio.") {|value| config['mute'] = value}
		options.on("-M", "--unmute", TrueClass, "Unmute the audio.") {|value| config['unmute'] = value}
		options.on("-v", "--volume number", Integer, "Set the volume to X percentage points.") {|value| config['volume'] = value}
		options.on("--volume-down [number]", Integer, "Decrease the volume by X percentage points (default 10).") {|value| config['volume-down'] = value || 10}
		options.on("--volume-up [number]", Integer, "Increase the volume by X percentage points (default 10).") {|value| config['volume-up'] = value || 10}
		options.on("--scan-to seconds", Integer, "Scan to an offset X seconds within the current track.") {|value| config['scan-to'] = value}
		options.on("--scan-backwards [seconds]", Integer, "Scan backwards X seconds within the current track (default 10).") {|value| config['scan-backwards'] = value || 10}
		options.on("--scan-forwards [seconds]", Integer, "Scan forwards X seconds within the current track (default 10).") {|value| config['scan-forwards'] = value || 10}
		options.on("--play-file name", Object, "Play the specified file or folder.") {|value| config['play-file'] = value}
		
		options.separator("Info on selected tracks:")
		options.on("-i", "--print-info format", Object, "For each track, print information in the given format.  If the following strings appear in the given format, they will be replaced with the corresponding track information:", 
			%q#"%a": artist#,
			%q#"%e": encoding#,
			%q#"%A": album#,
			%q#"%b": BPM (beats per minute)#,
			%q#"%c": composer#,
			%q#"%C": comment#,
			%q#"%d": disc number#,
			%q#"%D": disc count#,
			%q#"%E": Enabled status ("enabled" or "disabled")#,
			%q#"%l": location (file name/URL)#,
			%q#"%p": play count#,
			%q#"%q": equalizer#,
			%q#"%g": genre#,
			%q#"%G": grouping#,
			%q#"%n": name (title)#,
			%q#"%r": rating#,
			%q#"%s": skip count#,
			%q#"%t": track number#,
			%q#"%T": track count#,
			%q#"%v": volume adjustment#,
			%q#"%y": year#,
			%q#"%%": percent sign#
		) {|value| config['print-info'] = value}
		
		options.separator("General iTunes controls:")
		options.on("-a", "--add-file name", Object, "Add the specified file or folder to the library.") {|value| (config['add-file'] ||= []) << value}
		options.on("-q", "--quit", TrueClass, "Exit iTunes.") {|value| config['quit'] = value}
		options.on("--open-url url", Object, "Open the given URL.") {|value| config['open-url'] = value}
		options.on("--goto-store-home-page", TrueClass, "Go to the Store.") {|value| config['goto-store-home-page'] = value}
		options.on("--update-ipod", TrueClass, "Update the iPod.") {|value| config['update-ipod'] = value}
		
		options.separator("Playlist selection:")
		options.on("--library", TrueClass, "Operation will include the entire iTunes library.  Used by default unless other libraries are selected.") {|value| config['library'] = value}
		options.on("--current-playlist", TrueClass, "Operation will include the current playlist.") {|value| config['current-playlist'] = value}
		options.on("--playlist name", Object, "Operation will include the named playlist.  (This option can occur more than once.)") {|value| (config['playlist'] ||= []) << value}
		options.on("--all-playlists", TrueClass, "Operation will include all playlists.") {|value| config['all-playlists'] = value}
		options.on("--create-playlist name", Object, "Create a playlist with the specified name and include it in the operation.  (This option can occur more than once.)") {|value| (config['create-playlist'] ||= []) << value}
		options.on("--delete-playlist name", Object, "Delete the playlist with the specified name.  (This option can occur more than once.)") {|value| (config['delete-playlist'] ||= []) << value}
		
		options.separator("Track selection:")
		options.on("-f", "--find string", Object, "Operation will include all tracks in the specified playlist(s) where any field matches the specified string.  (This option can occur more than once.)") {|value| (config['find'] ||= []) << value}
		options.on("-c", "--current-track", TrueClass, "Operation will inclue the current track.") {|value| config['current-track'] = value}
		options.on("--selected-tracks", TrueClass, "Operation will include the selected tracks.") {|value| config['selected-tracks'] = value}
		options.on("--all-tracks", TrueClass, "Operation will include all tracks in the specified playlist(s).") {|value| config['all-tracks'] = value}
		options.on("--visible-find string", Object, "Operation will include all tracks in the specified playlist(s) where any visible field matches the specified string.  (This option can occur more than once.)") {|value| (config['visible-find'] ||= []) << value}
		options.on("--find-artist string", Object, "Operation will include all tracks in the specified playlist(s) where the artist matches the specified string.  (This option can occur more than once.)") {|value| (config['find-artist'] ||= []) << value}
		options.on("--find-album string", Object, "Operation will include all tracks in the specified playlist(s) where the album matches the specified string.  (This option can occur more than once.)") {|value| (config['find-album'] ||= []) << value}
		options.on("--find-composer string", Object, "Operation will include all tracks in the specified playlist(s) where the composer matches the specified string.  (This option can occur more than once.)") {|value| (config['find-composer'] ||= []) << value}
		options.on("--find-track-name string", Object, "Operation will include all tracks in the specified playlist(s) where the track name matches the specified string.  (This option can occur more than once.)") {|value| (config['find-track-name'] ||= []) << value}
		options.on("-F", "--play-found", TrueClass, "Play the first of the selected tracks.") {|value| config['play-found'] = value}
		
		options.separator("Operations on selected tracks:")
		options.on("--set-artist name", Object, "Set the artist for each track.") {|value| config['set-artist'] = value}
		options.on("--set-album name", Object, "Set the album for each track.") {|value| config['set-album'] = value}
		options.on("--set-bpm number", Integer, "Set the beats per minute for each track.") {|value| config['set-bpm'] = value}
		options.on("--set-comment string", Object, "Set the comment for each track.") {|value| config['set-comment'] = value}
		options.on("--set-composer string", Object, "Set the composer for each track.") {|value| config['set-composer'] = value}
		options.on("--set-disc-number number", Integer, "For each track, set the disc number.  (Used with multi-disc albums.)") {|value| config['set-disc-number'] = value}
		options.on("--set-disc-count number", Integer, "For each track, set the number of discs in the album.  (Used with multi-disc albums.)") {|value| config['set-disc-count'] = value}
		options.on("--set-enabled", TrueClass, "Enable the check box for each track.") {|value| config['set-enabled'] = value}
		options.on("--set-disabled", TrueClass, "Disable the check box for each track.") {|value| config['set-disabled'] = value}
		options.on("--set-eq name", Object, "Set the equalizer to the named preset.  Use 'None' to disable.") {|value| config['set-eq'] = value}
		options.on("--set-genre name", Object, "Set the genre for each track.") {|value| config['set-genre'] = value}
		options.on("--set-grouping string", Object, "Set the grouping for each track.") {|value| config['set-grouping'] = value}
		options.on("--set-name name", Object, "Set the name (title) for each track.") {|value| config['set-name'] = value}
		options.on("--set-play-count number", Integer, "Set the play count for each track.") {|value| config['set-play-count'] = value}
		options.on("--set-rating number", Integer, "Set the rating for each track.  Valid values are 0 through 5.") {|value| config['set-rating'] = value}
		options.on("--set-skip-count number", Integer, "Set the skip count for each track.") {|value| config['set-skip-count'] = value}
		options.on("--set-track-number number", Integer, "For each track, set its album track number.") {|value| config['set-track-number'] = value}
		options.on("--set-track-count number", Integer, "For each track, set the number of tracks on its album.") {|value| config['set-track-count'] = value}
		options.on("--set-track-volume percent", Integer, "Set the volume adjustment percentage for each track, from -100 to 100.  Negative numbers decrease the volume, positive numbers increase it.  0 means no adjustment.") {|value| config['set-track-volume'] = value}
		options.on("--set-year number", Integer, "Set the year of publication for each track.") {|value| config['set-year'] = value}
		options.on("--add-to-playlist name", Object, "Add selected tracks to the given playlist.") {|value| config['add-to-playlist'] = value}

		options.separator("Troubleshooting:")
		options.on("--debug", TrueClass, "When an error occurs, show a more detailed message.") {|value| config['debug'] = value}
		
		#Parse the options, printing usage if parsing fails.
		options.parse(arguments) rescue puts "#{$!}\nType '#{$0} --help' for valid options."
		
		config
		
	end


	#Set defaults for an existing set of options.
	def set_default_options(config=Hash.new)

		config['library'] = true unless (
			config.has_key?('library') or
			config.has_key?('current-playlist') or
			config.has_key?('playlist') or
			config.has_key?('all-playlists') or
			config.has_key?('create-playlist')
		)
		
		config
		
	end

	#Get playlists that will be operated on.
	def get_playlists (config)
	
		playlists = Array.new
		playlists << @interface.LibraryPlaylist if config['library']
		if config['current-playlist']
			playlists << @interface.CurrentPlaylist or raise "Can't find current playlist."
		end
		if config['all-playlists']
			if library_playlists = @interface.LibrarySource.Playlists
				library_playlists.each {|playlist| playlists << playlist}
			else
				raise "No playlists found."
			end
		end
		with_option_values('playlist', config) do |name|
			playlists << find_playlist(name) or raise "Can't find playlist #{name}"
		end
		
		#If creation of playlist(s) requested, create them and add to the collection.
		with_option_values('create-playlist', config) do |name|
			playlists << @interface.CreatePlaylist(name) or raise "Can't create playlist #{name}"
		end
		
		playlists
		
	end

	
	#Delete specified playlists.
	def delete_playlists (config)
		with_option_values('delete-playlist', config) do |name|
			find_playlist(name).delete
		end
	end
	
	
	#Find requested tracks.
	def get_tracks (config, playlists)
	
		tracks = []
	
		#Add current track to list if desired.
		if config['current-track']
			current_track = @interface.CurrentTrack or raise "No currently active/playing track." #"x << y.CurrentTrack or raise" won't work; nil is false but [nil] is true.
			tracks << current_track
		end
			
		#Add highlighted tracks to list if desired.
		if config['selected-tracks']
			if selected_tracks = @interface.SelectedTracks
				selected_tracks.each {|track| tracks << track}
			else
				raise "No tracks selected."
			end
		end
		
		#Find tracks in selected playlists.
		playlists.each do |playlist|
		
			#If all tracks are to be processed, select them all.
			if config['all-tracks']
				playlist.Tracks.each {|track| tracks << track}
			#Otherwise, see if user wishes to search for tracks.
			else
				search_playlist = lambda {|option, field_id|
					with_option_values(option, config) do |terms|
						if (results = playlist.Search(terms, field_id))
							results.each {|track| tracks << track}
						end
					end
				}
				search_playlist.call('find', 0)
				search_playlist.call('visible-find', 1)
				search_playlist.call('find-artist', 2)
				search_playlist.call('find-album', 3)
				search_playlist.call('find-composer', 4)
				search_playlist.call('find-track-name', 5)
			end
			
			#Import files to library and add resulting tracks to list.
			with_option_values('add-file', config) do |path|
				tracks.concat(add_file(File.expand_path(path), playlist))
			end
			
		end
		
		tracks
		
	end
	
	
	#Perform the operations specified in the config that affect iTunes itself.
	def perform_general_operations (config)
	
		specified?(config, 'mute') {@interface.Mute = 1}
		specified?(config, 'unmute') {@interface.Mute = 0}
		specified?(config, 'next-track') {@interface.NextTrack}
		specified?(config, 'scan-backwards') {|v| @interface.PlayerPosition -= v}
		specified?(config, 'scan-forwards') {|v| @interface.PlayerPosition += v}
		specified?(config, 'scan-to') {|v| @interface.PlayerPosition = v}
		specified?(config, 'volume-down') {|v| @interface.SoundVolume -= v}
		specified?(config, 'volume-up') {|v| @interface.SoundVolume += v}
		specified?(config, 'volume') {|v| @interface.SoundVolume = v}
		specified?(config, 'play-file') {|v| @interface.PlayFile(v)}
		specified?(config, 'open-url') {|v| @interface.OpenURL(v)}
		specified?(config, 'goto-store-home-page') {@interface.GotoMusicStoreHomePage}
		specified?(config, 'update-ipod') {@interface.UpdateIPod}
		specified?(config, 'pause') {@interface.Pause}
		specified?(config, 'play-pause') {@interface.PlayPause}
		specified?(config, 'previous-track') {@interface.PreviousTrack}
		specified?(config, 'stop') {@interface.Stop}
		specified?(config, 'play') {@interface.Play}
		specified?(config, 'quit') {@interface.Quit}
		
	end
	
	
	#Perform the operations specified in the config that affect the selected tracks.
	def perform_track_operations (config, tracks)
		
		#Operate on selected tracks.
		tracks.each do |track|
			specified?(config, 'set-artist') {|v| track.Artist = v}
			specified?(config, 'set-album') {|v| track.Album = v}
			specified?(config, 'set-bpm') {|v| track.BPM = v}
			specified?(config, 'set-comment') {|v| track.Comment = v}
			specified?(config, 'set-composer') {|v| track.Composer = v}
			specified?(config, 'set-disc-count') {|v| track.DiscCount = v}
			specified?(config, 'set-disc-number') {|v| track.DiscNumber = v}
			specified?(config, 'set-enabled') {|v| track.Enabled = true}
			specified?(config, 'set-disabled') {|v| track.Enabled = false}
			specified?(config, 'set-eq') {|v| track.EQ = v}
			specified?(config, 'set-genre') {|v| track.Genre = v}
			specified?(config, 'set-grouping') {|v| track.Grouping = v}
			specified?(config, 'set-name') {|v| track.Name = v}
			specified?(config, 'set-play-count') {|v| track.PlayedCount = v}
			specified?(config, 'set-rating') {|v| track.Rating = v * 20} #Rating is stored as a number from 1-100 internally, but 1-5 in interface.
			specified?(config, 'set-skip-count') {|v| track.SkippedCount = v}
			specified?(config, 'set-track-count') {|v| track.TrackCount = v}
			specified?(config, 'set-track-number') {|v| track.TrackNumber = v}
			specified?(config, 'set-track-volume') {|v| track.VolumeAdjustment = v}
			specified?(config, 'set-year') {|v| track.Year = v}
			specified?(config, 'print-info') {|v| puts track_info(track, v)}
			specified?(config, 'set-artist') {|v| track.Artist = v}
			specified?(config, 'add-to-playlist') {|v| find_playlist(v).AddTrack(track)}
		end
		
		#Play first of selected tracks if requested.
		specified?(config, 'play-found') {tracks[0].Play unless tracks.empty?}
		
	end
	
	
	#Take the specified format string with %x flags, and substitute info from the given track.
	def track_info (track, format)
	
		#Double percent signs should not be substituted, so work around them.
		segments = format.split(/%%/)
		
		#Substitute track info for markers.
		output_segments = segments.map do |segment|
			segment.gsub!(/%a/) {track.Artist}
			segment.gsub!(/%e/) {track.KindAsString} #The encoding.
			segment.gsub!(/%A/) {track.Album}
			segment.gsub!(/%b/) {track.BPM.to_s}
			segment.gsub!(/%c/) {track.Composer}
			segment.gsub!(/%C/) {track.Comment}
			segment.gsub!(/%d/) {track.DiscNumber.to_s}
			segment.gsub!(/%D/) {track.DiscCount.to_s}
			segment.gsub!(/%E/) {track.Enabled == 1 ? 'enabled' : 'disabled'}
			segment.gsub!(/%l/) {track.Location}
			segment.gsub!(/%p/) {track.PlayedCount.to_s}
			segment.gsub!(/%q/) {track.EQ}
			segment.gsub!(/%g/) {track.Genre}
			segment.gsub!(/%G/) {track.Grouping}
			segment.gsub!(/%n/) {track.Name}
			segment.gsub!(/%r/) {(track.Rating.to_i / 20).to_s}
			segment.gsub!(/%s/) {track.SkippedCount.to_s}
			segment.gsub!(/%t/) {track.TrackNumber.to_s}
			segment.gsub!(/%T/) {track.TrackCount.to_s}
			segment.gsub!(/%v/) {volume = track.VolumeAdjustment; (1 .. 98).include?(volume) ? volume.next.to_s : volume.to_s}
			segment.gsub!(/%y/) {track.Year.to_s}
			segment
		end

		#Replace double percent signs with single percent signs and return.
		output = output_segments.join('%')
		output += '%' if format =~ /%%$/ #split() doesn't create final empty field if string ends in a delimiter.
		output
		
	end
		
		
	private
	
		#Get selected playlist from iTunes.
		def find_playlist(name)
			@interface.LibrarySource.Playlists.ItemByName(name) or raise "Can't find playlist '#{name}'"
		end
		
		#Invoke a block with each of the values for the given option in the given configuration.
		def with_option_values (key, config)
			if config.has_key?(key)
				config[key].each {|value| yield value}
			end
		end
		
		#Add files to each specified playlist.
		def add_file(path, playlist)
			#Add the file and retrieve the status (since it's an asynchronous operation).
			status = playlist.AddFile(path) or raise "Can't find '#{path}'."
			#Wait for operation to complete.
			sleep 1 while status.InProgress
			#Return added track(s).
			tracks = Array.new
			status.Tracks.each {|track| tracks << track}
			tracks
		end
		
		#Perform an action only if the given option was specified.
		def specified? (config, key)
			yield config[key] if config.has_key?(key)
		end
				
end
